feat: AES-256-GCM message encryption with send/receive enforcement (#2643)#2644
Merged
jeremydmiller merged 21 commits intoJasperFx:mainfrom May 1, 2026
Merged
Conversation
…tion Introduces the abstractions and pure key-management types for the upcoming Wolverine application-layer encryption feature: IKeyProvider with InMemoryKeyProvider and CachingKeyProvider (TTL + single-flight dedup + evict-on-fault), the MessageEncryptionException hierarchy, and the on-wire content-type and header-name constants. EncryptingMessageSerializer follows in a later commit.
Decorator over an inner IMessageSerializer that encrypts envelope bodies with AES-256-GCM (12-byte nonce, 16-byte tag) and emits content-type application/wolverine-encrypted+json. Receive-side dispatches by content-type, decrypts, then unwraps to the inner serializer. Sync surface bridges to the async path via GetAwaiter().GetResult() to remain compatible with Wolverine's sync sites in Envelope.Data and BatchedSender. Documents IKeyProvider.GetKeyAsync as returning a borrowed reference.
…ration WolverineOptions.UseEncryption(provider) wraps the current default JSON serializer in an EncryptingMessageSerializer and makes it the default; RegisterEncryptionSerializer registers it alongside without becoming the default. MessageTypePolicies<T>.Encrypt() and the .Encrypted() endpoint extension swap envelope.Serializer (not just ContentType) so wire bytes are actually encrypted across all transports. Promotes WolverineOptions.TryFindSerializer to public so the per-type/per-endpoint paths can resolve the encrypting serializer by content-type.
Adds four acceptance tests in src/Testing/CoreTests/Acceptance/ encryption_acceptance.cs covering the encryption feature end-to-end: - Local-queue round-trip of an encrypted message through UseEncryption. - Envelope routing selects EncryptingMessageSerializer. - Two-host TCP scenarios that drive EncryptionKeyNotFoundException and MessageDecryptionException to the dead-letter queue. The error-policy tests adapt to Wolverine's existing tracking behaviour: WaitForAnyDeadLetteredEnvelope handles the case where deserialization fails before envelope.Message is materialized, and exception types are matched via AllRecordsInOrder().Exception because TCP receive paths use NullMessageStore and don't stamp Envelope.Failure.
The sample demonstrates RegisterEncryptionSerializer plus per-type .Encrypt() — PaymentDetails goes encrypted, OrderShipped stays plain. The docs page covers configuration, IKeyProvider (with the borrowed- reference contract), selective encryption, key rotation, the header-leak caveat, and error handling. The error-handling section notes that OnException<> retry policies do not apply to encryption exceptions because Wolverine's HandlerPipeline routes deserialization failures directly to the dead-letter queue; users should retry inside their IKeyProvider implementation if they need it.
Introduces internal static EncryptingMessageSerializer.BuildAad that
produces the canonical length-prefixed UTF-8 AAD layout
("wlv-enc-v1" || u16-be(len(MT)) || MT
|| u16-be(len(KeyId)) || KeyId
|| u16-be(len(InnerCT)) || InnerCT)
used to bind MessageType, KeyId, and InnerContentType into the AES-GCM
tag. Future tasks wire this into the encrypt/decrypt paths.
Lengths are validated via GetByteCount before any allocation, so
oversized inputs are rejected without the bytes ever being encoded.
Two tests cover the byte-format spec and null-MessageType normalization
(null and empty produce identical AAD).
…M tag Wires BuildAad into both encrypt and decrypt paths. WriteAsync calls BuildAad(envelope.MessageType, keyId, _inner.ContentType) and passes the AAD into AesGcm.Encrypt. ReadFromDataAsync reads InnerContentType from the headers, rebuilds AAD from the received MessageType, KeyId, and InnerContentType, and passes it into AesGcm.Decrypt. Tampering any of these three fields on the wire causes Decrypt to throw AuthenticationTagMismatchException, which the existing catch wraps as MessageDecryptionException and the pipeline routes to DLQ.
Adds a guard in HandlerPipeline.TryDeserializeEnvelope that runs before serializer selection: if the envelope's MessageType resolves to a type in WolverineOptions.RequiredEncryptedTypes, OR the envelope's Destination URI is in RequiredEncryptedListenerUris, AND the ContentType is not application/wolverine-encrypted+json, the envelope is routed to the dead-letter queue with EncryptionPolicyViolationException. No body bytes ever reach a serializer for a forged plaintext envelope. OR-semantics: either the type marker (from MessageTypePolicies<T>.Encrypt()) or the listener marker (from ListenerConfiguration<>.RequireEncryption()) is sufficient to require encryption. TCP two-host acceptance tests: - receive_unencrypted_message_for_required_type_routes_to_error_queue: forged plain JSON for a per-type-marked sensitive type → DLQ with EncryptionPolicyViolationException; handler never invoked. - receive_unencrypted_message_on_required_listener_routes_to_error_queue: same forge but the marker is on the listener via RequireEncryption(); exercises the destination-URI branch independently of type marking. - encrypted_message_for_required_type_round_trips_two_host: negative control — both sides marked Encrypt<T>(), full TCP encrypt/decrypt round-trip succeeds; proves the guard does not block legitimate encrypted traffic. - plain_message_for_unmarked_type_passes_when_encryption_is_configured: rolling-deploy scenario — receiver has UseEncryption() configured but the type is unmarked; plain JSON flows through normally with no DLQ. Removes the misleading encrypted_message_round_trips_end_to_end test. It claimed end-to-end coverage but published through a non-durable LocalQueue, which passes envelopes in-memory without invoking the cipher. Real end-to-end coverage now lives in the new TCP round-trip test above. The guard sits inside the existing try/catch in TryDeserializeEnvelope so any future fallibility in the lookup path is wrapped consistently with other deserialization failures, and Logger.Received(envelope) fires for rejected envelopes via the existing finally block.
…P/Local The per-listener encryption guard in HandlerPipeline.RequiresEncryption keyed off envelope.Destination. That field is populated on the receive side only by transports that round-trip Wolverine envelope properties (TCP, Local). Broker transports — RabbitMQ, Kafka, Azure Service Bus — do not populate it, so RequireEncryption() on those listeners was a silent no-op: forged plain-JSON envelopes were deserialized normally instead of being routed to the dead-letter queue. The guard now reads _endpoint.Uri from the HandlerPipeline's own listener endpoint (injected via the constructor used by every receive pipeline construction site). The lookup is transport-agnostic and no longer depends on a sender-controlled header. Defense-in-depth side benefit: even on transports that do populate envelope.Destination, the receiver no longer trusts that header for the policy decision. Also corrects the docstring on RequiredEncryptedListenerUris, which named the wrong populator method. Adds a regression test (RequiresEncryption_uses_listener_endpoint_uri_ not_envelope_destination) that constructs an envelope with Destination=null on a local-queue listener marked RequireEncryption() and asserts the guard still produces EncryptionPolicyViolationException via the listener endpoint URI. Verified to fail on the prior code where the lookup keyed off envelope.Destination.
…c honesty UseSystemTextJsonForSerialization no longer silently disables encryption when called after UseEncryption. The method now mirrors the existing UseNewtonsoftForSerialization pattern: if the current default serializer already wraps the inner JSON serializer (for encryption), the new STJ serializer is registered alongside but does not replace the default. Calling UseEncryption and UseSystemTextJsonForSerialization in either order now produces the same final state. Endpoint-level .Encrypted() now validates at host startup. The send-side configuration moved from extension methods on ISubscriberConfiguration<T> and LocalQueueConfiguration to instance methods on SubscriberConfiguration<T, TEndpoint> and ListenerConfiguration<TSelf, TEndpoint>, where the encrypting-serializer lookup runs at endpoint compile time. A misconfigured endpoint (.Encrypted() called without prior UseEncryption or RegisterEncryptionSerializer) now fails at host.StartAsync() with a clear InvalidOperationException, rather than at first message dispatch. EncryptOutgoingEndpointRule no longer carries the lazy lookup; it is constructed with the resolved serializer. Removes two no-op OnException<...>().MoveToErrorQueue() registrations from the receive-error acceptance tests. Wolverine routes deserialization failures to the dead-letter queue unconditionally, before user error- handling policies run, so those registrations had no effect; keeping them in the tests was misleading. Corrects the selection-precedence sentence in the encryption guide. The runtime applies per-type metadata rules after per-endpoint outgoing rules, so the actual last-write-wins precedence is per-type > endpoint > global default. For the encryption feature this distinction is moot — both markers swap to the same encrypting-serializer instance — but stating the incorrect order would mislead anyone applying the same mental model to custom envelope rules.
…ive-copy keys CachingKeyProvider used to start the single-flight inner fetch with the CancellationToken of whichever caller arrived first. If that caller cancelled, the shared Task<byte[]> faulted with OperationCanceledException and every concurrent caller — even those with healthy tokens — saw the cancellation. The shared inner task now runs with CancellationToken.None; per-caller cancellation is applied only at the caller's own await via Task.WaitAsync(token). A cancelled caller stops waiting; the inner KMS call continues to completion for any remaining waiters and the result is still cached. CachingKeyProvider's cache used to grow without bound. Entries were removed only when an access for the same key-id discovered an expired TTL, so workloads with many distinct key-ids (per-tenant keys, frequent rotation, test fixtures) leaked memory. The cache is now bounded by an LRU policy with a configurable maximum entry count (default 1024, configurable via a new optional constructor parameter). On insert when full, the least-recently-used entry is evicted; on access, the entry moves to the head of the LRU list. Backing store is a small internal class with a single lock around the dictionary index and the linked-list ordering. InMemoryKeyProvider used to alias the caller's byte arrays. Although the IKeyProvider documentation says callers must not mutate the returned key bytes, the in-box implementation should not require trust in that contract for its own state. The constructor now stores a defensive copy of each caller-supplied key array, so callers can zero or otherwise mutate their setup arrays without corrupting the provider.
…uard The send-side EncryptMessageTypeRule<T> encrypts every outbound message whose runtime type CanBeCastTo<T>() (polymorphic), but the receive-side guard in HandlerPipeline.RequiresEncryption checked RequiredEncryptedTypes.Contains(type) for exact membership. When .Encrypt() was registered on an interface or abstract base type, only the literal T was added to RequiredEncryptedTypes — so a forged plaintext envelope whose MessageType named a concrete subtype slipped past the guard and reached the JSON serializer, defeating the encryption guarantee. Adds WolverineOptions.IsEncryptionRequired(Type), which mirrors the send-side CanBeCastTo<T>() semantics: cache lookup first so previously computed answers survive any later mutation of RequiredEncryptedTypes, short-circuit on empty set to avoid unbounded cache growth on no-encryption hosts, then exact match against the set, then a polymorphic IsAssignableFrom scan, with the per-type result cached in a ConcurrentDictionary<Type,bool> for O(1) hot-path lookup. HandlerPipeline.RequiresEncryption now defers to this method.
…olation Receive-side audit of the message-encryption feature surfaced three silent-failure paths in the configuration surface plus weak test hygiene that would race once xUnit class-parallel tests share a process. Production fixes: - ListenerConfiguration.RequireEncryption() now mirrors the startup- time validation that Encrypted() already performs on the sender side. Without an encrypting serializer registered, every inbound envelope would be dead-lettered with no path to decrypt the encrypted ones; fail fast at host build instead. - WolverineOptions.UseEncryption() and RegisterEncryptionSerializer() refuse a second invocation. Repeating the call would wrap an already-wrapping serializer, producing envelopes that double- encrypt on send but only single-decrypt on receive — silent data loss with no error surface. - EncryptingMessageSerializer wraps wrong-sized keys returned from custom IKeyProvider implementations into EncryptionKeyNotFound- Exception with the offending key-id and length. Previously a 16-byte (or any non-32) key produced a raw CryptographicException from the AesGcm constructor, thrown outside the WriteAsync / ReadFromDataAsync try-catch, and surfaced to user code with no encryption context attached. Test hygiene: - encryption_acceptance handler-receive lists move from List<T> to ConcurrentBag<T>, and per-test isolation is enforced via the test class's constructor and IDisposable rather than scattered .Clear() calls in some tests. The static lists are a hazard once any class- parallel xUnit run shares a process; fix it now. - Add cross-inner-serializer round-trips (Newtonsoft sender / STJ receiver and vice versa) to lock the AAD-binding contract: sender inner ContentType travels via the InnerContentTypeHeader, and any JSON-flavor inner on the receive side can decrypt and dispatch.
Test-only changes from the encryption-feature audit. No production behavior change. - Rename three misleading tests so the name matches what they verify; back the two delegation tests with a TrackingInnerSerializer that observes the call instead of inferring it from a bubbling exception. - Remove four tautologies that exercised HashSet<T>, a property read, or `IsAbstract` reflection rather than encryption logic. - Extract the receive-side DLQ assertion into ShouldHaveDeadLetteredWith<TException>() so a future change to which tracking record carries the exception updates one helper, not five acceptance tests.
One small production guard plus tests for the remaining edge cases on the encryption feature. Production: - EncryptingMessageSerializer rejects a null/empty DefaultKeyId from custom IKeyProvider implementations up front. Previously the value reached BuildAad and the provider's lookup, where it surfaced as NullReferenceException or an opaque ArgumentNullException with no encryption context attached. Tests added (10 cases across the affected files): - HandlerPipeline no-endpoint constructor still applies the per-type encryption marker; the listener-URI branch correctly short-circuits when there is no endpoint to read .Uri from. - OperationCanceledException from a key provider propagates unchanged through both WriteAsync and ReadFromDataAsync — verified to NOT be wrapped as MessageEncryptionException. - Empty envelope.Data on receive produces MessageDecryptionException via the length guard rather than a span-slicing crash. - Negative AAD-binding test: an unmarked type routes through the inner serializer with no encryption headers stamped, even when an encrypting serializer is registered. - CachingKeyProvider with maxEntries=1 evicts immediately on a distinct second key, repeats are still cache hits. - TTL-boundary: an entry well within TTL stays cached (catches a > vs >= regression in the expiry check). - Polymorphic IsEncryptionRequired returns true when a type matches multiple registered marker interfaces. - Provider returning null/empty DefaultKeyId fails with a clean EncryptionKeyNotFoundException naming the offending provider. - Receive-side forgery: encrypted content-type plus plain-JSON body with plausible headers still fails the auth tag check. - Body of exactly 28 bytes (the length-guard minimum) reaches AesGcm.Decrypt and surfaces as MessageDecryptionException, not a raw CryptographicException.
…e contract Two boundary tests that close gaps the existing suite only covered indirectly. No production behavior change. - wire_does_not_contain_plaintext_when_encryption_is_required: in-process MITM TCP proxy between sender and receiver captures every byte flowing in the sender->receiver direction. Asserts the captured buffer contains the encrypted-content-type marker but not the plaintext canary, and the receiver still successfully decrypts and dispatches. This is the only test that proves the bytes Wolverine actually transmits are not the plaintext — every other test inspects the serializer's output or relies on a successful round-trip. - durable_persistence_path_serializes_ciphertext_not_plaintext: locks the chain that keeps plaintext off disk in the outbox path — EnvelopeRules apply (assigning the encrypting serializer) before PersistOrSendAsync, and the persistence layer reads envelope.Data which is lazy and triggers Serializer.Write on first access. Both materialisation entry points are exercised: the sync Data getter (current outbox path) and GetDataAsync (the async-serializer path a future migration would use). Out of scope and noted in the test: SendRawMessageAsync, which accepts pre-serialized bytes by design.
- WolverineOptions: RequiredEncryptedTypes, RequiredEncryptedListenerUris,
IsEncryptionRequired are internal — the receive-side guard cannot be
silently disabled by host code mutating a public collection.
- EncryptingMessageSerializer:
- WriteMessage(object) and ReadFromData(byte[]) throw InvalidOperationException
instead of returning the inner serializer's plaintext under the encrypted
content-type.
- ReadFromDataAsync rejects envelopes missing the inner-content-type header
after the tag check (defense-in-depth; legit Wolverine senders always
write it).
- BuildAad encodes directly into a single buffer.
- Class remarks document that key-provider calls use CancellationToken.None
and that no-envelope overloads now throw.
- Exception hierarchy: KeyId moved off the abstract base onto the two
subclasses that actually carry one (KeyNotFound, Decryption); policy
violations no longer carry an empty KeyId.
- InMemoryKeyProvider.GetKeyAsync returns a defensive copy so a misbehaving
caller cannot corrupt subsequent encryptions.
- Sample (EncryptionDemo): demonstrates RequireEncryption() on a listener,
routes only sensitive types to the encrypted queue, header comment now
honestly describes that LocalQueue is in-process pass-through.
- Docs: configuration-order warning corrected — Use*Json* setters preserve
encryption, so order is no longer load-bearing.
- Tests updated to match new contracts; rolling-deploy test gains a
clarifying comment about the unmarked listener.
Encryption acceptance and options tests pulled IMessageBus directly from the root provider with host.Services.GetRequiredService<IMessageBus>(). IMessageBus is registered as scoped, so under Host.CreateDefaultBuilder's default ValidateScopes=true (active in Development env, e.g. when run from an IDE) every test that did this threw InvalidOperationException before reaching the assertion. Switch all 13 sites to host.MessageBus() — the idiomatic Wolverine helper that constructs a bus from IWolverineRuntime and bypasses scope resolution, matching how the rest of the acceptance suite (indefinite_scheduled_retries, streaming_handler_support, multi_tenancy, …) gets its bus. Sample EncryptionDemo updated for the same reason. Drop the now-unused Microsoft.Extensions.DependencyInjection usings.
This was referenced May 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds opt-in application-layer message encryption (AES-256-GCM) to Wolverine, with first-class send-side and receive-side configuration, a key-provider abstraction with
bounded caching, and end-to-end test coverage including a wire-level negative test.
Closes #2643.
Why
Wolverine currently relies on transport-level TLS for in-flight confidentiality and broker-level encryption at-rest. That is not enough for:
PaymentDetails,MedicalRecord) while keeping the rest in plain JSON for debuggability.Users currently roll their own envelope encryption on top of Wolverine, which is error-prone (nonce reuse, missing AEAD binding, unprotected
MessageTypeenabling routingattacks).
What changed
Public surface
Cryptographic core
EncryptingMessageSerializer(Wolverine/Runtime/Serialization/Encryption/) wrapping any innerIMessageSerializer. AES-256-GCM with a fresh 12-byte random nonce perencryption and a 16-byte auth tag.
MessageType+key-id+ inner-content-type + awlv-enc-v1magic prefix into the auth tag, so tampering any of those three fails decryption (blockscross-handler / cross-content-type routing attacks).
application/wolverine-encrypted+json.Key handling
IKeyProviderabstraction:string DefaultKeyId { get; }+ValueTask<byte[]> GetKeyAsync(string keyId, CancellationToken ct).InMemoryKeyProviderfor tests/samples — defensively copies keys in and out so caller mutation cannot corrupt subsequent encryptions.CachingKeyProviderdecorator: bounded LRU, thread-safe, single-flight per key-id, per-caller cancellation isolation (one cancelled caller never poisons a shared keyfetch).
Receive-side enforcement
HandlerPipeline.RequiresEncryptionrejects forged plaintext envelopes for marked types or marked listeners before any serializer runs. Routes them to the dead-letterqueue with
EncryptionPolicyViolationException.Exception hierarchy
MessageEncryptionException(abstract base, noKeyId).EncryptionKeyNotFoundException(carriesKeyId).MessageDecryptionException(carriesKeyId; tag mismatch or malformed body).EncryptionPolicyViolationException(no key involved — policy-level).Configuration safety
UseEncryptionand double-RegisterEncryptionSerializerthrow at config time.UseSystemTextJsonForSerialization/UseNewtonsoftForSerializationare order-insensitive — they only replace the default when its content-type isapplication/json, socalling them after
UseEncryptionis a no-op against the default.Encrypted()/RequireEncryption()validated at startup, not at first message.API hygiene
RequiredEncryptedTypes,RequiredEncryptedListenerUris,IsEncryptionRequired) isinternalso the guard cannot be silentlydisabled by host code mutating a public collection.
EncryptingMessageSerializer.WriteMessage(object)andReadFromData(byte[])(no-envelope overloads) throwInvalidOperationExceptionrather than silently returning theinner serializer's plaintext under the encrypted content-type.
Tests
All new tests live in
src/Testing/CoreTests/:Acceptance/encryption_acceptance.cs— two-host round-trip, key-bytes mismatch → DLQ, unknown key-id → DLQ, per-type / per-listener / polymorphic-supertype guards,rolling-deploy unmarked type passes, wire-bytes TCP-MITM proxy test confirms the canary plaintext never appears on the wire, durable-persistence path test locks
ciphertext-not-plaintext at the data-materialisation point.
Runtime/Serialization/Encryption/EncryptingMessageSerializerTests.cs— AAD binding, nonce uniqueness, tampering negatives (key-id / message-type / inner-content-type /ciphertext / tag), wrong-key-length wrapping, cross-flavor sender/receiver round-trips, no-envelope overload throws, missing-header defense-in-depth.
Runtime/Serialization/Encryption/CachingKeyProviderTests.cs— single-flight, per-caller cancellation isolation, bounded LRU, eviction race safety.Runtime/Serialization/Encryption/InMemoryKeyProviderTests.cs— key-length validation, default-key-id presence, defensive copy.Runtime/Serialization/Encryption/WolverineOptionsEncryptionTests.cs—UseEncryptionswap,RegisterEncryptionSerializercoexistence, per-type / per-endpoint routing,double-call throws, listener-URI registration, no-endpoint pipeline still enforces per-type marker, JSON-after-encryption order-insensitivity.
Runtime/Serialization/Encryption/WolverineOptionsIsEncryptionRequiredTests.cs— exact match, polymorphic match, multi-marker, cache memoization.Runtime/Serialization/Encryption/exception_hierarchy.cs— base/subclass relationships, message content does not leak plaintext.All 87 encryption tests pass on net8.0, net9.0 and net10.0, including under
DOTNET_ENVIRONMENT=Development(scope validation enabled).Docs and sample
docs/guide/runtime/encryption.mdcovering quickstart,IKeyProvider, selective encryption, key rotation, integrity guarantees and theheader-leak caveat, error handling, and what's not included.
src/Samples/EncryptionDemo/showing per-typeEncrypt<T>()and listener-sideRequireEncryption().Out of scope
MessageTypeis integrity-protected via AAD but not confidential. The doc page tells operators to put sensitive values in the body, notin headers.
IKeyProviderover your KMS; adapter packages may ship later.DeduplicationId/MessageIdentityif needed.Risks / open questions
IAsyncMessageSerializerinterface has noCancellationToken, so key-provider calls duringWriteAsync/ReadFromDataAsyncuseCancellationToken.None. A slow KMSfetch cannot be cancelled by host shutdown — providers should apply their own internal timeouts. Documented in the class XML doc.